跳到主要内容

低代码编辑器:自定义JS

前面实现了内置的几个动作,这节来实现下自定义 JS。

比如 amis

它就支持通过代码来自定义动作。

而且自定义 JS 可以拿到 doAction 方法来执行其他动作:

可以通过 context 拿到组件信息。

我们也来实现下。

创建 Setting/actions/CustomJS.tsx

import { useState } from "react";
import { useComponetsStore } from "../../../stores/components";
import MonacoEditor, { OnMount } from "@monaco-editor/react";

export interface CustomJSConfig {
type: "customJS";
code: string;
}

export interface CustomJSProps {
defaultValue?: string;
onChange?: (config: CustomJSConfig) => void;
}

export function CustomJS(props: CustomJSProps) {
const { defaultValue, onChange } = props;

const { curComponentId } = useComponetsStore();
const [value, setValue] = useState(defaultValue);

function codeChange(value?: string) {
if (!curComponentId) return;

setValue(value);

onChange?.({
type: "customJS",
code: value!,
});
}

const handleEditorMount: OnMount = (editor, monaco) => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyJ, () => {
editor.getAction("editor.action.formatDocument")?.run();
});
};

return (
<div className="mt-[40px]">
<div className="flex items-start gap-[20px]">
<div>自定义 JS</div>
<div>
<MonacoEditor
width={"600px"}
height={"400px"}
path="action.js"
language="javascript"
onMount={handleEditorMount}
onChange={codeChange}
value={value}
options={{
fontSize: 14,
scrollBeyondLastLine: false,
minimap: {
enabled: false,
},
scrollbar: {
verticalScrollbarSize: 6,
horizontalScrollbarSize: 6,
},
}}
/>
</div>
</div>
</div>
);
}

和其他动作表单不同的是这里用 monaco editor。

然后在 ActionModal 里用一下:

切换自定义 JS 的 tab 时,渲染 CustomJS 组件。

顺便把类型也改一下,加上 CustomJSConfig 的类型

import { Modal, Segmented } from "antd";
import { useState } from "react";
import { GoToLink, GoToLinkConfig } from "./actions/GoToLink";
import { ShowMessage, ShowMessageConfig } from "./actions/ShowMessage";
import { CustomJS, CustomJSConfig } from "./actions/CustomJS";

export interface ActionModalProps {
visible: boolean;
handleOk: (config?: ActionConfig) => void;
handleCancel: () => void;
}

export type ActionConfig = GoToLinkConfig | ShowMessageConfig | CustomJSConfig;

export function ActionModal(props: ActionModalProps) {
const { visible, handleOk, handleCancel } = props;

const [key, setKey] = useState<string>("访问链接");
const [curConfig, setCurConfig] = useState<ActionConfig>();

return (
<Modal
title="事件动作配置"
width={800}
open={visible}
okText="确认"
cancelText="取消"
onOk={() => handleOk(curConfig)}
onCancel={handleCancel}>
<div className="h-[500px]">
<Segmented
value={key}
onChange={setKey}
block
options={["访问链接", "消息提示", "自定义 JS"]}
/>
{key === "访问链接" && (
<GoToLink
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
{key === "消息提示" && (
<ShowMessage
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
{key === "自定义 JS" && (
<CustomJS
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
</div>
</Modal>
);
}

ComponentEvent 里渲染的时候也支持 customJS,并改下 ts 类型:

import { Collapse, Input, Select, CollapseProps, Button } from "antd";
import { useComponetsStore } from "../../stores/components";
import { useComponentConfigStore } from "../../stores/component-config";
import type { ComponentEvent } from "../../stores/component-config";
import { ActionConfig, ActionModal } from "./ActionModal";
import { useState } from "react";
import { DeleteOutlined } from "@ant-design/icons";

export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } =
useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [actionModalOpen, setActionModalOpen] = useState(false);
const [curEvent, setCurEvent] = useState<ComponentEvent>();

if (!curComponent) return null;

function deleteAction(event: ComponentEvent, index: number) {
if (!curComponent) {
return;
}

const actions = curComponent.props[event.name]?.actions;

actions.splice(index, 1);

updateComponentProps(curComponent.id, {
[event.name]: {
actions: actions,
},
});
}

const items: CollapseProps["items"] = (
componentConfig[curComponent.name].events || []
).map((event) => {
return {
key: event.name,
label: (
<div className="flex justify-between leading-[30px]">
{event.label}
<Button
type="primary"
onClick={(e) => {
e.stopPropagation();

setCurEvent(event);
setActionModalOpen(true);
}}>
添加动作
</Button>
</div>
),
children: (
<div>
{(curComponent.props[event.name]?.actions || []).map(
(item: ActionConfig, index: number) => {
return (
<div>
{item.type === "goToLink" ? (
<div
key="goToLink"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
跳转链接
</div>
<div>{item.url}</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
{item.type === "showMessage" ? (
<div
key="showMessage"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
消息弹窗
</div>
<div>{item.config.type}</div>
<div>{item.config.text}</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
{item.type === "customJS" ? (
<div
key="customJS"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
自定义 JS
</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
</div>
);
}
)}
</div>
),
};
});

function handleModalOk(config?: ActionConfig) {
if (!config || !curEvent || !curComponent) {
return;
}

updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: [
...(curComponent.props[curEvent.name]?.actions || []),
config,
],
},
});

setActionModalOpen(false);
}

return (
<div className="px-[10px]">
<Collapse
className="mb-[10px]"
items={items}
defaultActiveKey={componentConfig[
curComponent.name
].events?.map((item) => item.name)}
/>
<ActionModal
visible={actionModalOpen}
handleOk={handleModalOk}
handleCancel={() => {
setActionModalOpen(false);
}}
/>
</div>
);
}

测试下:

动作添加成功。

在 json 里可以看到这个配置:

接下来只要 Preview 的时候实现这种 action 的执行就好了。

支持 customJS 的 action 执行,顺便改下类型。

props[event.name] = () => {
eventConfig?.actions?.forEach((action: ActionConfig) => {
if (action.type === "goToLink") {
window.location.href = action.url;
} else if (action.type === "showMessage") {
if (action.config.type === "success") {
message.success(action.config.text);
} else if (action.config.type === "error") {
message.error(action.config.text);
}
} else if (action.type === "customJS") {
const func = new Function(action.code);
func();
}
});
};

测试下:

这样就实现了自定义 JS 的执行。

然后给执行的函数加上一些参数:

new Function 可以传入任意个参数,最后一个是函数体,前面都会作为函数参数的名字。

然后调用的时候传入参数。

我们这里只传入了当前组件的 name、props 还有一个方法。

const func = new Function("context", action.code);
func({
name: component.name,
props: component.props,
showMessage(content: string) {
message.success(content);
},
});

测试下:

这样,自定义 JS 的功能就完成了。

但现在有个问题:

我们上节做了动作的新增、删除,并没有做编辑。

这对于跳转链接、消息弹窗这种动作还好,参数比较简单。

但是对于自定义 JS,写一段 JS 成本还是挺高的,删了再重写体验不好,所以我们得支持下编辑。

改下 ComponentEvent 组件:

<div
style={{ position: "absolute", top: 10, right: 30, cursor: "pointer" }}
onClick={() => editAction(item)}>
<EditOutlined />
</div>

加一个绝对定位的 icon。

点击的时候打开弹窗:

function editAction(config: ActionConfig) {
if (!curComponent) {
return;
}

setActionModalOpen(true);
}

测试下:

能打开弹窗,但是还没回显内容。

在 ActionModal 传入 action 来回显:

import { Modal, Segmented } from "antd";
import { useEffect, useState } from "react";
import { GoToLink, GoToLinkConfig } from "./actions/GoToLink";
import { ShowMessage, ShowMessageConfig } from "./actions/ShowMessage";
import { CustomJS, CustomJSConfig } from "./actions/CustomJS";

export type ActionConfig = GoToLinkConfig | ShowMessageConfig | CustomJSConfig;

export interface ActionModalProps {
visible: boolean;
action?: ActionConfig;
handleOk: (config?: ActionConfig) => void;
handleCancel: () => void;
}

export function ActionModal(props: ActionModalProps) {
const { visible, action, handleOk, handleCancel } = props;

const map = {
goToLink: "访问链接",
showMessage: "消息提示",
customJS: "自定义 JS",
};

const [key, setKey] = useState<string>("访问链接");
const [curConfig, setCurConfig] = useState<ActionConfig>();

useEffect(() => {
if (action?.type) {
setKey(map[action.type]);
}
}, [action]);

return (
<Modal
title="事件动作配置"
width={800}
open={visible}
okText="确认"
cancelText="取消"
onOk={() => handleOk(curConfig)}
onCancel={handleCancel}>
<div className="h-[500px]">
<Segmented
value={key}
onChange={setKey}
block
options={["访问链接", "消息提示", "自定义 JS"]}
/>
{key === "访问链接" && (
<GoToLink
key="goToLink"
defaultValue={
action?.type === "goToLink" ? action.url : ""
}
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
{key === "消息提示" && (
<ShowMessage
key="showMessage"
value={
action?.type === "showMessage"
? action.config
: undefined
}
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
{key === "自定义 JS" && (
<CustomJS
key="customJS"
defaultValue={
action?.type === "customJS" ? action.code : ""
}
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
</div>
</Modal>
);
}

然后在 ComponentEvent 里传入这个参数:

const [curAction, setCurAction] = useState<ActionConfig>();

测试下:

这样,回显就完成了。

然后保存的时候也要处理下:

记录下当前编辑的 action 的 index。

保存的时候如果有 curAction,就是修改,没有的话才是新增。

import { Collapse, Input, Select, CollapseProps, Button } from "antd";
import { useComponetsStore } from "../../stores/components";
import { useComponentConfigStore } from "../../stores/component-config";
import type { ComponentEvent } from "../../stores/component-config";
import { ActionConfig, ActionModal } from "./ActionModal";
import { useState } from "react";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";

export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } =
useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [actionModalOpen, setActionModalOpen] = useState(false);
const [curEvent, setCurEvent] = useState<ComponentEvent>();
const [curAction, setCurAction] = useState<ActionConfig>();
const [curActionIndex, setCurActionIndex] = useState<number>();

if (!curComponent) return null;

function deleteAction(event: ComponentEvent, index: number) {
if (!curComponent) {
return;
}

const actions = curComponent.props[event.name]?.actions;

actions.splice(index, 1);

updateComponentProps(curComponent.id, {
[event.name]: {
actions: actions,
},
});
}

function editAction(config: ActionConfig, index: number) {
if (!curComponent) {
return;
}
setCurAction(config);
setCurActionIndex(index);

setActionModalOpen(true);
}

const items: CollapseProps["items"] = (
componentConfig[curComponent.name].events || []
).map((event) => {
return {
key: event.name,
label: (
<div className="flex justify-between leading-[30px]">
{event.label}
<Button
type="primary"
onClick={(e) => {
e.stopPropagation();

setCurEvent(event);
setActionModalOpen(true);
}}>
添加动作
</Button>
</div>
),
children: (
<div>
{(curComponent.props[event.name]?.actions || []).map(
(item: ActionConfig, index: number) => {
return (
<div>
{item.type === "goToLink" ? (
<div
key="goToLink"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
跳转链接
</div>
<div>{item.url}</div>
<div
style={{
position: "absolute",
top: 10,
right: 30,
cursor: "pointer",
}}
onClick={() =>
editAction(item, index)
}>
<EditOutlined />
</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
{item.type === "showMessage" ? (
<div
key="showMessage"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
消息弹窗
</div>
<div>{item.config.type}</div>
<div>{item.config.text}</div>
<div
style={{
position: "absolute",
top: 10,
right: 30,
cursor: "pointer",
}}
onClick={() =>
editAction(item, index)
}>
<EditOutlined />
</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
{item.type === "customJS" ? (
<div
key="customJS"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
自定义 JS
</div>
<div
style={{
position: "absolute",
top: 10,
right: 30,
cursor: "pointer",
}}
onClick={() =>
editAction(item, index)
}>
<EditOutlined />
</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
</div>
);
}
)}
</div>
),
};
});

function handleModalOk(config?: ActionConfig) {
if (!config || !curEvent || !curComponent) {
return;
}

if (curAction) {
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: curComponent.props[curEvent.name]?.actions.map(
(item: ActionConfig, index: number) => {
return index === curActionIndex ? config : item;
}
),
},
});
} else {
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: [
...(curComponent.props[curEvent.name]?.actions || []),
config,
],
},
});
}

setCurAction(undefined);

setActionModalOpen(false);
}

return (
<div className="px-[10px]">
<Collapse
className="mb-[10px]"
items={items}
defaultActiveKey={componentConfig[
curComponent.name
].events?.map((item) => item.name)}
/>
<ActionModal
visible={actionModalOpen}
handleOk={handleModalOk}
action={curAction}
handleCancel={() => {
setCurAction(undefined);
setActionModalOpen(false);
}}
/>
</div>
);
}

测试下:

action 的新增和修改正常。

这时候我发现虽然最终保存的是对的,回显的不对:

如上图,我修改下面的 action 的时候,回显的依然是之前的值,但保存是对的。

这是为什么呢?我们不是传了参数了么:

因为我们是用非受控模式写的,传的参数作为表单的默认值:

所以修改 defaultValue 并不会修改表单值。

有回显需求的表单,必须用受控模式来写。

我们改一下:

当传入 value 参数的时候,同步设置内部的 value

测试下:

这样就好了。

案例代码上传了小册仓库,可以切换到这个 commit 查看:

git reset --hard 29562eb568bdc05e4efbdd02ba4f817f47201279

总结

这节我们实现了自定义 JS。

通过 monaco editor 来输入代码,然后通过 new Function 来动态执行代码,执行的代码可以访问 context,传入一些属性方法。

然后我们实现了动作的编辑,点击编辑按钮会在弹窗回显 action,保存之后会修改 json。

主要回显的表单一定是受控模式,这样才可以随时 value,不然只能设置初始值 defaultValue

这样,内置动作、自定义 JS 的动作就都完成了。